% vim: ts=4 sts=4 sw=4 et tw=75
\chapter{数据处理}
\label{chap:data_processing}

\marginpar{67}
Awk 最初的设计目标是用于日常的数据处理, 例如信息查询, 数据验证, 以及数据
转换与归约, 我们已经在第 \ref{chap:an_awk_tutorial} 章与第
\ref{chap:the_awk_language} 章见到了关于它们的简单例子. 在这一章,
我们会按照
相同的思路, 考虑一些更加复杂的任务, 大多数例子一次只处理一行, 但是最后一节
讨论如何处理占据多行的输入记录.

Awk 程序通常按照增量模式开发: 先写上几行, 测试一下, 然后再添加几行, 再测试,
如此进行下去. 这本书里的大多数比较大的程序都是按照这种模式开发的.

也可以按照传统的方式开发 awk 程序, 先拟好程序的主体框架, 然后查询手册,
但是, 通过修改已有的程序来达到我们自己想要的效果, 通常来说会更加容易.
于是, 这本书里的程序扮演了另一个角色: 通过例子来提供实用的编程模型.

\section{数据转换与归约}
\label{sec:data_transformation_and_reduction}

Awk 最常用的一个功能是把数据从一种形式转换成另一种形式, 通常情况下, 是把
一种程序的输出格式, 转换成另一种程序要求的格式. 另一个常用的功能是从一个大
数据集中提取相关的数据, 通常伴随着汇总信息的重新格式化与准备, 这一节包含了
许多关于这些主题的例子.

\subsection{列求和}
\label{subsec:Summing_columns}

我们已经看到了 ``两行'' awk 程序的几种变体, 这些程序对单个字段上的所有
数字求和.
下面这个程序执行的工作更加复杂, 但却是比较典型的数据归约任务. 每一个输入行
都含有若干个字段, 每一个字段都包含数字, 程序的任务是计算每一列的和, 而不管
该行有多少列.
\begin{awkcode}
     # sum1 - print column sums
     #   input:  rows of numbers
     #   output: sum of each column
     #     missing entries are treated as zeros
     
         { for (i = 1; i <= NF; i++)
               sum[i] += $i
           if (NF > maxfld)
               maxfld = NF
         }
     END { for (i = 1; i <= maxfld; i++) {
               printf("%g", sum[i])
               if (i < maxfld)
                   printf("\t")
               else
                   printf("\n")
           }
         }
\end{awkcode}
\marginpar{68}
变量的自动初始化在这里显得非常方便, 因为 \verb'maxfld' (到目前为止, 字段数的
最大值) 自动从 0 开始, 随着程序的运行, 所有的项都被放入数组 \verb'sum'
中, 虽然只有到程序运行结束, 我们才能知道数组中到底有多少项. 值得注意的是,
如果输入文件为空, 那么程序什么也不会打印出来.

程序不需要知道一行有多少个字段, 这对我们写程序来说就非常方便, 但是它不检
查参与运算的项是否都是数值, 也不检查每行的字段数是否相同. 下面的程序做的
是同样的事情, 但是 它会检查每行的字段数是否与第一行的相同:
\begin{awkcode}
     # sum2 - print column sums
     #     check that each line has the same number of fields
     #        as line one
     
     NR==1 { nfld = NF }
           { for (i = 1; i <= NF; i++)
                 sum[i] += $i
             if (NF != nfld)
                 print "line " NR " has " NF " entries, not " nfld
           }
     END   { for (i = 1; i <= nfld; i++)
                 printf("%g%s", sum[i], i < nfld ? "\t" : "\n")
           }
\end{awkcode}
我们还修正了位于 \END 的输出代码, 这段代码显示了如何利用条件表达式, 使得
在列与列之间插入一个制表符, 在最后一列之后插入一个换行符.

现在, 假设某些字段不是数值型, 所以它们不能被计算在内. 策略是新增一个数组
\verb'numcol', 用于跟踪数值型字段, 函数 \verb'isnum' 用于检查某项是否是
一个数值, 由于用到了函数, 所以测试只需要在一个地方完成, 这样做有助于将来%
\marginpar{69}%
对程序进行修改. 如果程序足够相信它的输入, 那么只需要查看第 1 行就够了,
我们仍然需要 \verb'nfld', 因为在 \END 中, \verb'NF' 的 值是零.
\begin{awkcode}
     # sum3 - print sums of numeric columns
     #     input:  rows of integers and strings
     #     output: sums of numeric columns
     #       assumes every line has same layout
     
     NR==1 { nfld = NF
             for (i = 1; i <= NF; i++)
                 numcol[i] = isnum($i)
           }
     
           { for (i = 1; i <= NF; i++)
                 if (numcol[i])
                     sum[i] += $i
           }
     
     END   { for (i = 1; i <= nfld; i++) {
                 if (numcol[i])
                     printf("%g", sum[i])
                 else
                     printf("--")
                 printf(i < nfld ? "\t" : "\n")
             }
           }
     
     function isnum(n) { return n ~ /^[+-]?[0-9]+$/ }
\end{awkcode}
函数 \verb'isnum' 把数值定义成一个或多个数字, 可能有前导符号. 关于数值更加
一般的定义可以在 \ref{sec:patterns} 节的正则表达式那里找到.

\begin{exercise}
    \label{exer:sum3}
    修改程序 \verb'sum3', 使它忽略空行.
\end{exercise}
\begin{exercise}
    为数值添加更加一般的正则表达式. 它会如何影响运行时间?
\end{exercise}
\begin{exercise}
    \label{exer:without_for_test}
    如果把第 2 个 \verb'for' 语句的 \verb'numcol' 测试拿掉, 会产生什
    么影响?
\end{exercise}
\begin{exercise}
    \label{exer:accumulate}
    写一个程序, 这个程序读取一个\ \mbox{条目}--数额\ 对列表, 对列表中的
    每一个 条目, 累加它的数额; 在结束时, 打印条目以及它的总数额, 条目
    按照字母顺序排列.
\end{exercise}

\subsection{计算百分比与分位数}
\label{subsec:computing_percentages_and_quantiles}

假设我们不想知道每列的总和, 但是想知道每一列所占的百分比, 要完成这个工作就
必须对数据遍历两遍. 如果只有一列是数值, 而且也没有太多的数据, 最简单的办法
是在第一次遍历时, 把数值存储在一个数组中, 第二次遍历时计算百分比并把它打印
出来:
\begin{awkcode}
     # percent
     #   input:  a column of nonnegative numbers
     #   output: each number and its percentage of the total
     
         { x[NR] = $1; sum += $1 }
     
     END { if (sum != 0)
               for (i = 1; i <= NR; i++)
                   printf("%10.2f %5.1f\n", x[i], 100*x[i]/sum)
         }
\end{awkcode}
\marginpar{70}

虽然包含了稍微复杂一点的转换关系,
但是它可以用于许多事情, 例如调整学生的成绩, 使得成绩分布符
合某种曲线. 一旦成绩计算完毕 (0 到 100 之间的数), 显示成一个直方图可能会比
较有趣:
\begin{awkcode}
    # histogram
    #   input:  numbers between 0 and 100
    #   output: histogram of deciles
    
        { x[int($1/10)]++ }
    
    END { for (i = 0; i < 10; i++)
              printf(" %2d - %2d: %3d %s\n",
                  10*i, 10*i+9, x[i], rep(x[i],"*"))
          printf("100:      %3d %s\n", x[10], rep(x[10],"*"))
        }
    
    function rep(n,s,   t) {  # return string of n s's
        while (n-- > 0)
            t = t s
        return t
    }
\end{awkcode}
需要注意的是后缀递减运算符 \verb'--' 如何控制 \verb'while' 循环.

我们可以用随机生成的成绩测试 \verb'histogram'. 管道线上的第一个程序随机
生成 200 个 0 到 100 的整数, 并把这些整数输送给 \verb'histogram'
\begin{awkcode}
    awk '
    # generate random integers
    BEGIN { for (i = 1; i <= 200; i++)
                print int(101*rand())
          }
    ' |
    awk -f histogram
\end{awkcode}
它的输出是
\begin{awkcode}
      0 -  9:  20 ********************
     10 - 19:  18 ******************
     20 - 29:  20 ********************
     30 - 39:  16 ****************
     40 - 49:  23 ***********************
     50 - 59:  17 *****************
     60 - 69:  22 **********************
     70 - 79:  20 ********************
     80 - 89:  20 ********************
     90 - 99:  22 **********************
    100:        2 **
\end{awkcode}

\marginpar{71}
\begin{exercise}
    \label{exer:star_num}
    根据比例决定星号的个数, 使得当数据过多时, 一行的长度不会超过屏幕的宽度.
\end{exercise}

\begin{exercise}
    \label{exer:bucket}
    修改 \verb'histogram', 把输入分拣到指定数量的桶中, 根据目前为止看到的
    数据调整每个桶的范围.
\end{exercise}

\subsection{带逗号的数}
\label{subsec:numbers_with_commas}

设想我们有一张包含了许多数的表, 每个数都有逗号与小数点, 就像
\verb'12,345.67', 因为第一个逗号会终止 awk 对数的解析, 所以它们不能直接
相加, 必须首先把逗号移除:
\begin{awkcode}
    # sumcomma - add up numbers containing commas
    
        { gsub(/,/, ""); sum += $0 }
    END { print sum }
\end{awkcode}
\verb'gsub(/,/, "")' 把每一个逗号都替换成空字符串, 也就是删除逗号.

这个程序不检查逗号是否处于正确的位置,
也不在答案中打印逗号. 往数字中加入逗号只
需要很少的工作量, 下一个程序就展示了这点, 它为数字加上逗号, 保留两位小数.
这个程序的结构是非常值得效仿的: 一个函数只负责添加逗号, 剩下的部分只管读取
与打印, 一旦测试通过, 新的函数就可以被包含到最终的程序中.

基本思路是在一个循环中, 从小数点开始, 从右至左, 在适当的位置插入逗号, 每
次迭代都把一个逗号插到最左边的三个数字的前面, 这三个数字后面跟着一个逗号或
小数点, 而且每一个逗号的前面至少有一个数字. 算法使用递归处理负数: 如果
输入参数是负数, 那么函数 \verb'addcomma' 使用正数来调用它自身, 返回时再加
上负号.
\begin{awkcode}
    # addcomma - put commas in numbers
    #   input:  a number per line
    #   output: the input number followed by
    #      the number with commas and two decimal places 
    
        { printf("%-12s %20s\n", $0, addcomma($0)) }
    
    function addcomma(x,   num) {
        if (x < 0)
            return "-" addcomma(-x)
        num = sprintf("%.2f", x)   # num is dddddd.dd
        while (num ~ /[0-9][0-9][0-9][0-9]/)
            sub(/[0-9][0-9][0-9][,.]/, ",&", num)
        return num
    }
\end{awkcode}
\marginpar{72}
请注意 \verb'&' 的用法, 通过文本替换, \verb'sub' 在每三个数字的前面插入一个
逗号.

这是某些测试数据的输出:
\begin{awkcode}
    0                            0.00
    -1                          -1.00
    -12.34                     -12.34
    12345                   12,345.00
    -1234567.89         -1,234,567.89
    -123.                     -123.00
    -123456               -123,456.00
\end{awkcode}

\begin{exercise}
    \label{exer:sumcomma}
    修复 \verb'sumcomma' (带逗号的数字求和程序): 检查数字中的逗号
    是否处于正确的位置上.
\end{exercise}

\subsection{字段固定的输入}
\label{subsec:fixed_field_input}

对于那些出现在宽度固定的字段里的信息, 在直接使用它们之前, 通常需要某种形式的
预处理. 有些程序 (例如电子表格) 在固定的列上面放置序号, 而不是给它们带上
字段分隔符. 如果序号太宽,
这些列就会邻接在一起. 字段固定的数据最适合用 \verb'substr' 处理,
它可以将任意组合的列挑选出来. 举例来说, 假设每行的前6个字符包含一个日期, 
日期的形式是 \verb'mmddyy', 如果我们想让它们按照日期排序, 最简单的办法是
先把日期转换成 \verb'yymmdd' 的形式:
\begin{awkcode}
    # date convert - convert mmddyy into yymmdd in $1
    
    { $1 = substr($1,5,2) substr($1,1,2) substr($1,3,2); print }
\end{awkcode}
如果输入是按照月份排序的, 就像这样
\begin{awkcode}
    013042 mary's birthday
    032772 mark's birthday
    052470 anniversary
    061209 mother's birthday
    110175 elizabeth's birthday
\end{awkcode}
那么程序的输出是
\begin{awkcode}
    420130 mary's birthday
    720327 mark's birthday
    700524 anniversary
    090612 mother's birthday
    751101 elizabeth's birthday
\end{awkcode}
\marginpar{73}
现在数据已经准备好, 可以按照年, 月, 日来排序了.

\begin{exercise}
    \label{exer:date}
    将日期转换成某种形式, 这种形式允许你对日期进行算术运算, 例如计算某两
    个日期之间的天数.
\end{exercise}

\subsection{程序的交叉引用检查}
\label{subsec:program_cross_reference_checking}

Awk 经常用于从其他程序的输出中提取信息, 有时候这些输出仅仅是一些同种类的行
的集合, 在这种情况下, 使用字段分割操作或函数 \verb'substr'
是非常方便且合适的. 然而, 有时候产生输出的程序本来就打算将输出表示成人类
可读的形式, 对于这种情况, awk 需要把精心格式化过的输出重新还原
成机器容易处理的形式, 只有这样, 才能从互不相关的数据中提取信息, 下面是一个
简单的例子.

大型程序通常由多个源文件组成, 知道哪些文件定义了哪些函数, 以及这些函数在哪
里被使用到 --- 可以带来许多方便之处 (有时候这是非常重要的). 为了完成这个
任务, Unix 提供了命令 \verb'nm', \verb'nm' 从一个目标文件集合中提取信息,
并打印成一张精心格式化过的列表, 这张表包含了名字, 定义, 以及名字在哪里被使用
到, \verb'nm' 的典型输出是
\begin{awkcode}
    file.o:
    00000c80 T _addroot
    00000b30 T _checkdev
    00000a3c T _checkdupl
             U _chown
             U _client
             U _close
    funmount.o:
    00000000 T _funmount
             U cerror
\end{awkcode}
只有一个字段的行 (比如 \verb'file.o') 是文件名, 有两个字段的行 (比如 
\verb'U _close') 表示的是名字被使用到的地方, 有三个字段的行表示的是名字
被定义的地方. \verb'T' 表示这个定义是一个文本符号 (函数), \verb'U' 表示 
这个名字是未定义的.

如果直接使用这些未处理过的信息, 去判断某个文件定义了哪些名字, 又或者是
某个符号都在哪里被用到 --- 将会非常得麻烦, 因为每个符号都没有和它所在的
文件名放在一起. 对于稍微大型的 C 程序来说, \verb'nm' 的输出将会非常得长
--- awk 源代码由 9 个文件组成, 它的 \verb'nm' 输出超过了 850 行.
\marginpar{74}
一个仅仅 3 行的 awk 程序就可以把文件名加到每个符号的前面, 经过它的处理,
后续的程序仅通过一行就可以获取到有用的信息:
\begin{awkcode}
    # nm.format - add filename to each nm output line
    
    NF == 1 { file = $1 }
    NF == 2 { print file, $1, $2 }
    NF == 3 { print file, $2, $3 }
\end{awkcode}
把上面 \verb'nm' 的输出作为 \filename{nm.format} 的输入, 结果是
\begin{awkcode}
    file.o: T _addroot
    file.o: T _checkdev
    file.o: T _checkdupl
    file.o: U _chown
    file.o: U _client
    file.o: U _close
    funmount.o: T _funmount
    funmount.o: U cerror
\end{awkcode}
现在, 如果有其他程序想要对输出作进一步的处理就容易多了.

上面的输出没有包括行号信息, 也没有指出某个名字在文件中被用到了多少次, 
但是这些信息很容易通过文本编辑器或另一个 awk 程序获取. 本小节的 awk 程序
不依赖于目标文件的编程语言, 所以它比通常的交叉引用工具更加灵活, 也更加
简短.

\subsection{格式化输出}
\label{subsec:formatted_output}

接下来我们要用 awk 赚点钱, 或者是打印支票. 输入数据由多行组成, 每一行都包括
支票编号, 金额, 收款人, 字段之间用制表符分开, 输出是标准的支票格式: 8行高,
第2行与第3行是支票编号与日期, 都向右缩进 45 个空格, 第4行是收款人, 占用
45 个字符宽的区域, 紧跟在它后面的是 3 个空格, 再后面是金额, 第 5 行是金额
的大写形式, 其他行都是空白. 支票看起来就像这样:
\begin{mdframed}
\begin{awkcode}

                                             1026
                                             Aug 31, 2015
Pay to Mary R. Worth--------------------------------   $123.45
the sum of one hundred twenty three dollars and 45 cents exactly



\end{awkcode}
\end{mdframed}
这是打印支票的 awk 程序:
\marginpar{75}
\begin{awkcode}
    # prchecks - print formatted checks
    #   input:  number \t amount \t payee
    #   output: eight lines of text for preprinted check forms
    
    BEGIN {
        FS = "\t"
        dashes = sp45 = sprintf("%45s", " ")
        gsub(/ /, "-", dashes)        # to protect the payee
        "date" | getline date         # get today's date
        split(date, d, " ")
        date = d[2] " " d[3] ", " d[6]
        initnum()    # set up tables for number conversion
    }
    NF != 3 || $2 >= 1000000 {        # illegal data
        printf("\nline %d illegal:\n%s\n\nVOID\nVOID\n\n\n", NR, $0)
        next                          # no check printed
    }
    {   printf("\n")                  # nothing on line 1
        printf("%s%s\n", sp45, $1)    # number, indented 45 spaces
        printf("%s%s\n", sp45, date)  # date, indented 45 spaces
        amt = sprintf("%.2f", $2)     # formatted amount
        printf("Pay to %45.45s   $%s\n", $3 dashes, amt)  # line 4
        printf("the sum of %s\n", numtowords(amt))        # line 5
        printf("\n\n\n")              # lines 6, 7 and 8
    }
    
    function numtowords(n,   cents, dols) { # n has 2 decimal places
        cents = substr(n, length(n)-1, 2)
        dols = substr(n, 1, length(n)-3)
        if (dols == 0)
            return "zero dollars and " cents " cents exactly"
        return intowords(dols) " dollars and " cents " cents exactly"
    }
    
    function intowords(n) {
        n = int(n)
        if (n >= 1000)
            return intowords(n/1000) " thousand " intowords(n%1000)
        if (n >= 100)
            return intowords(n/100) " hundred " intowords(n%100)
        if (n >= 20)
            return tens[int(n/10)] " " intowords(n%10)
        return nums[n]
    }
    
    function initnum() {
        split("one two three four five six seven eight nine " \
              "ten eleven twelve thirteen fourteen fifteen " \
              "sixteen seventeen eighteen nineteen", nums, " ")
        split("ten twenty thirty forty fifty sixty " \
              "seventy eighty ninety", tens, " ")
    }
\end{awkcode}

程序中包含了几个比较有趣的部分. 首先, 要注意到我们在 \BEGIN 中是如何利用
\marginpar{76}
\verb'sprintf' 来生成空格字符串的, 并且通过替换将空格字符串转换成
破折号. 还要注意的是, 在函数 \verb'initnum' 中, 我们如何通过行的延续与
字符串拼接来创建 \verb'split' 的参数 --- 这是很常见的编程技巧.

日期通过
\begin{awkcode}
    "date" | getline date   # get today's date
\end{awkcode}
从系统获取, 该行执行 \verb'date', 再把它的输出输送给 \verb'getline'. 为了把
\begin{awkcode}
    Wed Jun 17 13:39:36 EDT 1987
\end{awkcode}
转换成 
\begin{awkcode}
    Jun 17, 1987
\end{awkcode}
我们需要自己做一些处理工作.
(在不支持管道的非Unix平台上, 该程序需要做些修改才能正确运行)

函数 \verb'numtowords' 与 \verb'intowords' 把数字转换成对应的单词, 转换过程
非常直接 (虽然程序用了一半的代码来做这件事). \verb'intowords' 是一个递归
函数, 它调用自身来处理一个规模较小的子问题, 这是本章出现过的第 2 个递归
函数, 在后面我们还会遇到更多这样的函数. 在很多情况下, 为了把一个大问题分解
成相对容易解决的小问题, 递归都是一种非常有效的方法.

\begin{exercise}
    利用前面提到过的程序 \verb'addcomma', 为金额加上逗号.
\end{exercise}

\begin{exercise}
    对于负的, 或特别大的金额, 程序 \verb'prchecks' 处理得并不是很好. 修改 
    程序: 拒绝金额为负的打印请求, 同时能将数额特别巨大的金额
    分成两行打印出来.
\end{exercise}

\begin{exercise}
    \label{exer:numtowords}
    函数 \verb'numtowords' 有时会在一行中连着打印两个空格, 还会打印出像 
    ``one dollars'' 这样有错误的句子, 你会如何消除这些瑕疵?
\end{exercise}

\begin{exercise}
    修改 \verb'prchecks': 在适当的地方, 为金额的大写形式加上
    连字符, 比如 ``twenty-one dollars''.
\end{exercise}

\section{数据验证}
\label{sec:data_validation}

Awk 的另一个常用功能是数据验证: 确保数据是合法的, 或至少合理的. 本节包含了
几个用于验证输入有效性的小程序, 例如, 考虑上一节出现的列求和程序, 有没有
这样一种情况: 在本应是数值的字段上出现了非数值的量 (或反之) ? 下面这个程序 
与列求和程序非常相似, 但没有求和操作:
\marginpar{77}
\begin{awkcode}
    # colcheck - check consistency of columns
    #   input:  rows of numbers and strings
    #   output: lines whose format differs from first line
    
    NR == 1	{
        nfld = NF
        for (i = 1; i <= NF; i++)
           type[i] = isnum($i)
    }
    {   if (NF != nfld)
           printf("line %d has %d fields instead of %d\n",
              NR, NF, nfld)
        for (i = 1; i <= NF; i++)
           if (isnum($i) != type[i])
              printf("field %d in line %d differs from line 1\n",
                 i, NR)
    }
    
    function isnum(n) { return n ~ /^[+-]?[0-9]+$/ }
\end{awkcode}
同样, 我们把数值看成是仅由数字构成的序列, 可能有前导符号, 如果想让这个判断
更加完整, 请参考 \ref{sec:patterns} 节关于正则表达式的讨论.

\subsection{对称的分隔符}
\label{subsec:balanced_delimiters}

本书有一个机器可读的版本, 在该版本中, 每一个程序都由一种特殊行开始,
这个特殊
行以 \verb'.P1' 打头, 同样, 程序以一种特殊行结束, 该行以 \verb'.P2' 打头.
这些特殊行叫作 ``文本格式化'' 命令, 有了这些命令的帮助, 在排版书籍的时候,
程序可以以一种容易识别的字体显示出来. 因为程序之间不能互相嵌套, 所以这些 
文本格式化命令必须按照交替序列, 轮流出现:
\begin{awkcode}
    .P1 .P2 .P1 .P2 ... .P1 .P2
\end{awkcode}
如果其中一个被漏掉了, 那么排版软件的输出将会是完全混乱的. 为了确保书籍被
正确得排版, 我们写了这个小程序, 它可以用于检查分隔符是否按照正确的顺序出现,
程序虽小, 但却是检查程序的典型代表:
\begin{awkcode}
    # p12check - check input for alternating .P1/.P2 delimiters
    
    /^\.P1/ { if (p != 0)
                  print ".P1 after .P1, line", NR
              p = 1
            }
    /^\.P2/ { if (p != 1)
                  print ".P2 with no preceding .P1, line", NR
              p = 0
            }
    END     { if (p != 0) print "missing .P2 at end" }
\end{awkcode}
如果分隔符按照正确的顺序出现, 那么变量 \verb'p' 就会按照 \texttt{0 1 0 1 0
... 1 0} 的规律变化, 否则, 一条错误消息被打印出来, 消息含有发生错误时,
当前输入行 所在的行号.
\marginpar{78}

\begin{exercise}
    \label{exer:p12check}
    如何修改这个程序, 使得它可以处理具有多种分隔符的文本?
\end{exercise}

\subsection{密码文件检查}
\label{subsec:password_file checking}

Unix 系统中的密码文件含有授权用户的用户名及其相关信息, 密码文件的每一行都
由 7 个字段组成:
\begin{awkcode}
    root:qyxRi2uhuVjrg:0:2::/:
    bwk:1L./v6iblzzNE:9:1:Brian Kernighan:/usr/bwk:
    ava:otxs1oTVoyvMQ:15:1:Al Aho:/usr/ava:
    uucp:xutIBs2hKtcls:48:1:uucp daemon:/usr/lib/uucp:uucico
    pjw:xNqY//GDc8FFg:170:2:Peter Weinberger:/usr/pjw:
    mark:j0z1fuQmqIvdE:374:1:Mark Kernighan:/usr/bwk/mark:
    ...
\end{awkcode}
第1个字段是用户的登录名, 只能由字母或数字组成. 第 2 个字段是加密后的登录
密码, 如果密码是空的, 那么任何人都可以利用这个用户名来登录系统, 如果这个
字段非空, 那么只有知道密码的用户才能成功登录. 第 3 与第 4 个字段是数字,
第 6 个字段以 \verb'/' 开始. 下面这个程序打印的行不符合前面所描述的结构,
顺带打印它们的行号, 及一条恰当的诊断消息, 每个晚上都让这个程序运行一遍可
以让系统更加健康, 远离攻击.
\begin{awkcode}
    # passwd - check password file
    
    BEGIN {
        FS = ":" }
    NF != 7 {
        printf("line %d, does not have 7 fields: %s\n", NR, $0) }
    $1 ~ /[^A-Za-z0-9]/ {
        printf("line %d, nonalphanumeric user id: %s\n", NR, $0) }
    $2 == "" {
        printf("line %d, no password: %s\n", NR, $0) }
    $3 ~ /[^0-9]/ {
        printf("line %d, nonnumeric user id: %s\n", NR, $0) }
    $4 ~ /[^0-9]/ {
        printf("line %d, nonnumeric group id: %s\n", NR, $0) }
    $6 !~ /^\// {
        printf("line %d, invalid login directory: %s\n", NR, $0) }
\end{awkcode}

这是增量开发程序的好例子: 每当有人认为需要添加新的检查条件时, 只需要往
程序中添加即可, 其他部分保持不动, 于是程序会越来越完善.

\subsection{自动生成数据验证程序}
\label{subsec:generating_data_validation_programs}

\marginpar{79}
密码文件检查程序由我们手工编写而成, 不过更有趣的方式是把条件与消息集合
自动转化成检查程序. 下面这个集合含有几个错误条件及其对应的提示信息, 
这些错误条件取自上一个程序. 如果某个输入行满足错误条件, 对应的提示信息
就会被打印出来.
\begin{awkcode}
    NF != 7                 does not have 7 fields
    $1 ~ /[^A-Za-z0-9]/     nonalphanumeric user id
    $2 == ""                no password
\end{awkcode}
下面这个程序把\ \mbox{条件}\mbox{-}消息\ 对转化成检查程序:
\begin{awkcode}
    # checkgen - generate data-checking program
    #     input:  expressions of the form: pattern tabs message
    #     output: program to print message when pattern matches
    
    BEGIN { FS = "\t+" }
    { printf("%s {\n\tprintf(\"line %%d, %s: %%s\\n\",NR,$0) }\n",
          $1, $2)
    }
\end{awkcode}
程序的输出是一系列的条件与打印消息的动作:
\begin{awkcode}
    NF != 7 {
        printf("line %d, does not have 7 fields: %s\n",NR,$0) }
    $1 ~ /[^A-Za-z0-9]/ {
        printf("line %d, nonalphanumeric user id: %s\n",NR,$0) }
    $2 == "" {
        printf("line %d, no password: %s\n",NR,$0) }
\end{awkcode}
检查程序运行时, 如果当前输入行使得条件为真, 那么程序就会打印出一条消息, 
消息含有当前输入行的行号, 错误消息, 及当前输入行的内容.
需要注意的是, 在程序
\verb'checkgen' 中, \verb'printf' 格式字符串的某些特殊字符需要用双引号
括起来, 只有这样才能生成有效的程序. 举例来说, 为了输出一个 \verb'%',
必须将它写成 \verb'%%'; 为了输出 \verb'\n', 必须写成 \verb'\\n'.

用一个 awk 程序来生成另一个 awk 程序是一个应用很广泛的技巧 (不限于 awk
语言), 在本书后面的章节里我们还会看到更多这样的例子.

\begin{exercise}
    \label{exer:checkgen}
    增强 \verb'checkgen' 的功能, 使得我们可以原封不动地向程序传递一段
    代码, 例如创建一个 \verb'BEGIN' 来设置字段分隔符.
\end{exercise}

\subsection{AWK 的版本}
\label{subsec:which_version_of_awk}

Awk 可以用来检验程序, 也可以用来组织程序测试. 本小节包含的程序有几分近亲
相奸的味道: 用 awk 程序检查 awk 程序.

Awk 的新版可能包含更多的内建变量与内建函数, 而老程序有可能不小心用到了
\marginpar{80}
这些名字, 例如, 老程序用 \verb'sub' 命名一个变量, 而在新版 awk 中,
\verb'sub' 是一个内建函数. 下面的程序可以用来检测这种错误:
\begin{awkcode}
    # compat - check if awk program uses new built-in names
    
    BEGIN { asplit("close system atan2 sin cos rand srand " \
                   "match sub gsub", fcns)
            asplit("ARGC ARGV FNR RSTART RLENGTH SUBSEP", vars)
            asplit("do delete function return", keys)
          }
    
          { line = $0 }
    
    /"/   { gsub(/"([^"]|\\")*"/, "", line) }     # remove strings,
    /\//  { gsub(/\/([^\/]|\\\/)+\//, "", line) } # reg exprs,
    /#/   { sub(/#.*/, "", line) }                # and comments
    
          { n = split(line, x, "[^A-Za-z0-9_]+")  # into words
            for (i = 1; i <= n; i++) {
                if (x[i] in fcns)	
                    warn(x[i] " is now a built-in function")
                if (x[i] in vars)
                    warn(x[i] " is now a built-in variable")
                if (x[i] in keys)
                    warn(x[i] " is now a keyword")
            }
          }
    
    function asplit(str, arr) {  # make an assoc array from str
        n = split(str, temp)
        for (i = 1; i <= n; i++)
            arr[temp[i]]++
        return n
    }
    
    function warn(s) {
        sub(/^[ \t]*/, "")
        printf("file %s, line %d: %s\n\t%s\n", FILENAME, FNR, s, $0)
    }
\end{awkcode}

程序中真正复杂的地方在于替换语句: 在一个输入行被检查之前, 语句试图从输入
行中移除被双引号包围的字符串, 正则表达式, 以及注释. 替换语句并不是非常
完善, 所以有些行可能没有得到正确的处理.

第 1 个 \verb'split' 函数的第 3 个参数被解释成一个正则表达式, 输入行中,
被该表达式匹配的最左最长子字符串成为字段分隔符. \verb'split' 把不含
字母或数字的字符串当作分隔符, 将输入行分割成一个个子字符串, 每个子字符串
仅含有字母或数字. 这个分割操作把所有的运算符或标点符号一次性移除.
\marginpar{81}

函数 \verb'asplit' 类似于 \verb'split', 但前者创建一个数组, 数组的下标是字
符串内的单词, 然后就可以测试新来的单词是否在数组内.

如果把文件 \filename{compat} 作为输入, 输出是:
\begin{awkcode}
    file tmp.awk, line 12: gsub is now a built-in function
        /\//  { gsub(/\/([^\/]|\\\/)+\//, "", line) } # reg exprs,
    file tmp.awk, line 13: sub is now a built-in function
        /#/   { sub(/#.*/, "", line) }                # and comments
    file tmp.awk, line 26: function is now a keyword
        function asplit(str, arr) {  # make an assoc array from str
    file tmp.awk, line 30: return is now a keyword
        return n
    file tmp.awk, line 33: function is now a keyword
        function warn(s) {
    file tmp.awk, line 34: sub is now a built-in function
        sub(/^[ \t]*/, "")
    file tmp.awk, line 35: FNR is now a built-in variable
        printf("file %s, line %d: %s\n\t%s\n", FILENAME, FNR, s, $0)
\end{awkcode}

\begin{exercise}
    重写 \verb'compat', 不用 \verb'asplit', 而是用正则表达式来识别关键词,
    内建函数等. 比较两个版本的复杂度与速度.
\end{exercise}

\begin{exercise}
    因为 awk 的变量不需要事先声明, 所以如果用户不小心把变量名写错了, awk 并
    不会检测到该错误. 写一个程序, 这个程序搜索文件中只出现一次的名字.
    为了让这个程序更具实用价值, 你可能需要对函数的定义及其用到的变量花点
    心思.
\end{exercise}

\section{打包与拆包}
\label{sec:bundle_and_unbundle}

在讨论多行记录之前, 让我们先考虑一种特殊的情况: 如何将多个 ASCII 文件打包
(bundle) 成一个文件, 在打包完之后, 还可以将它们还原 (unbundle) 成原来的文
件. 这一节包含的两个程序分别用来完成这两件事情, 我们可以用它们将多个小文件
打包成一个文件, 从而节省磁盘空间或方便邮寄.

程序 \verb'bundle' 非常简短, 简短到你可以直接在命令行上输入, 它所做的工作
仅仅是为每一行加上文件名前缀, 文件名可以通过内建变量 \verb'FILENAME' 得到.
\begin{awkcode}
    # bundle - combine multiple files into one
    { print FILENAME, $0 }
\end{awkcode}

对应的 \verb'unbundle' 程序只是稍微需要花点心思:
\marginpar{82}
\begin{awkcode}
    # unbundle - unpack a bundle into separate files
    
    $1 != prev { close(prev); prev = $1 }
               { print substr($0, index($0, " ") + 1) >$1 }
\end{awkcode}
如果遇到一个新文件, 则关闭之前打开的文件, 如果文件不是很多 (小于同时处于打开
状态的文件数的最大值), 那么这一行可以省略.

实现 \verb'bundle' 与 \verb'unbundle' 的方法还有很多种, 但是这里介绍的方法是
最简单的, 而且对于比较短的文件, 空间效率也比较高. 另一种组织方式是在每
一个文件之前, 添加一行带有文件名的, 容易识别的行, 这样的话, 文件名只需要
出现一次.

\begin{exercise}
    比较不同版本 \verb'bundle' 与 \verb'unbundle' 的时间效率和空间效率,
    这些不同的版本用到了不同的头部信息与尾部信息, 对程序的性能与复杂性
    之间的折衷进行评价.
\end{exercise}

\section{多行记录}
\label{sec:multiline_records}

到目前为止遇到的记录都是由单行组成的, 然而, 还有大量的数据, 其每一条记录
都由多行组成, 比如地址薄
\begin{awkcode}
    Adam Smith
    1234 Wall St., Apt. 5C
    New York, NY 10021
    212 555-4321
\end{awkcode}
或参考文献
\begin{awkcode}
    Donald E. Knuth
    The Art of Computer Programming
    Volume 2: Seminumerical Algorithms, Second Edition
    Addison-Wesley, Reading, Mass.
    1981
\end{awkcode}
或个人笔记
\begin{awkcode}
    Chateau Lafite Rothschild 1947
    12 bottles @ 12.95
\end{awkcode}

如果大小合适, 结构也很规范, 那么创建并维护这些信息相对来说还是比较容易的,
在效果上, 每一条记录都等价于一张索引卡片. 与单行数据相比, 使用 awk 处理 
多行数据所付出的工作量只是稍微多了一点. 我们将展示几种处理多行数据的方法.

\subsection{由空行分隔的记录}
\label{subsec:records_separated_by_blank_lines}

假设我们有一本地址薄, 其每一条记录的前面 4 行分别是名字, 街道地址, 城市
和州, 在这 4 行之后, 可能包含一行额外的信息, 记录之间由一行空白行分开:
\marginpar{83}
\begin{awkcode}
    Adam Smith
    1234 Wall St., Apt. 5C
    New York, NY 10021
    212 555-4321

    David W. Copperfield
    221 Dickens Lane
    Monterey, CA 93940
    408 555-0041
    work phone 408 555-6532
    Mary, birthday January 30

    Canadian Consulate
    555 Fifth Ave
    New York, NY
    212 586-2400
\end{awkcode}

如果记录是由空白行分隔的, 那么它们可以被直接处理: 若记录分隔符 \verb'RS'
被设置成空值 (\verb'RS=""'), 则每一个行块都被当成一个记录, 于是 
\begin{awkcode}
    BEGIN { RS = "" }
    /New York/
\end{awkcode}
打印所有的, 含有 \verb'New York' 的记录, 而不管这个记录有多少行:
\begin{awkcode}
    Adam Smith
    1234 Wall St., Apt. 5C
    New York, NY 10021
    212 555-4321
    Canadian Consulate
    555 Fifth Ave
    New York, NY
    212 586-2400
\end{awkcode}
如果记录按照这种方式打印出来, 则输出记录之间是不会有空白行的, 输入格式并
不会被保留下来. 为了解决这个问题, 最简单的办法是把输出记录分隔符 \verb'ORS'
设置成 \verb'\n\n':
\begin{awkcode}
    BEGIN { RS = ""; ORS = "\n\n" }
    /New York/
\end{awkcode}

假设我们想要输出 \verb'Smith''s 的全名和他的电话号码 (也就是第 1 行以 
\verb'Smith' 结尾的记录的第 1 行与第 4 行), 如果每一行都表示一个字段, 那么
就比较容易, 只要把 \verb'FS' 设置成 \verb'\n' 即可:
\marginpar{84}
\begin{awkcode}
    BEGIN           { RS = ""; FS = "\n" }
    $1 ~ /Smith$/   { print $1, $4 } # name, phone
\end{awkcode}
程序的输出是 
\begin{awkcode}
    Adam Smith 212 555-4321
\end{awkcode}
前面提过, 不管 \verb'FS' 的值是什么, 换行符总是多行记录的字段分隔符之一.
如果 \verb'RS' 被设置成 \verb'""', 则默认的字段分隔符就是空格符, 制表符,
以及换行符; 如果 \verb'FS' 是 \verb'\n', 则换行符就是唯一的字段分隔符.

\subsection{处理多行记录}
\label{subsec:processing_multiline_records}

如果已经有一个程序可以以行为单位对输入进行处理, 那么我们只需要再写 2 个
awk 程序, 就可以把原来的程序应用到多行记录上. 第 1 个程序把多行记录组合成
单行记录, 然后再由已存在的程序进行处理, 最后, 第 2 个程序再把输出转换成多行
格式. (我们假设行的长度不会超过 awk 的上限)

为了使过程更加具体, 现在让我们用 Unix 命令 \verb'sort' 对地址薄进行排序,
下面的程序 \verb'pipeline' 按照姓氏对输入进行排序:
\begin{awkcode}
    # pipeline to sort address list by last names

    awk '
    BEGIN { RS = ""; FS = "\n" }
          { printf("%s!!#", x[split($1, x, " ")])
            for (i = 1; i <= NF; i++)
                printf("%s%s", $i, i < NF ? "!!#" : "\n")
          }
    ' |
    sort |
    awk '
    BEGIN { FS = "!!#" }
          { for (i = 2; i <= NF; i++)
                printf("%s\n", $i)
            printf("\n")
          }
    '
\end{awkcode}

第 1 个程序中, 函数 \verb'split($1, x, " ")' 把每个记录的第 1 行切割并保
存到数组 \verb'x' 中, 返回元素的个数, 于是, 姓氏保存在 
元素 \verb'x[split($1, x, " ")]' 中 (前提是记录的第1行的最后一个单词确实是
姓氏). 对每一条多行记录, 第 1 个程序都会创建一个单行记录, 记录包括姓氏,
后面跟着字符串 \verb'!!#', 再后面是原多行记录的各个字段 (字段之间也是通过
字符串 \verb'!!#' 分隔). 只要是输入数据中没有出现的, 并且在排序时
可以排在输入数据之前的字符串, 都可以用来代替 \verb'!!#'. \verb'sort' 之后
的程序通过分隔符 \verb'!!#' 识别原来的字段, 并重构出多行记录.

\begin{exercise}
    修改第 1 个 awk 程序: 检查输入数据中是否包含魔术字符串
    \verb'!!#'.
\end{exercise}

\marginpar{85}
\subsection{带有头部和尾部的记录}
\label{subsec:records_with_headers_and_trailers}

有时候, 记录通过一个头部信息与一个尾部信息来识别, 而不是字段分隔符. 考虑 
一个简单的例子, 仍然是地址薄, 不过每个记录都带有一个头部信息, 该信息
指出了记录的某些特征 (比如职业), 跟在头部后面的是名字, 每条记录 (除了最
后一条) 都由一个尾部结束, 尾部由一个空白行组成:
\begin{awkcode}
    accountant
    Adam Smith
    1234 Wall St., Apt. 5C
    New York, NY 10021

    doctor - ophthalmologist
    Dr. Will Seymour
    798 Maple Blvd.
    Berkeley Heights, NJ 07922

    lawyer
    David W. Copperfield
    221 Dickens Lane
    Monterey, CA 93940

    doctor - pediatrician
    Dr. Susan Mark
    600 Mountain Avenue
    Murray Hill, NJ 07974
\end{awkcode}
为了打印所有医生的记录, 范围模式是最简单的办法:
\begin{awkcode}
    /^doctor/, /^$/
\end{awkcode}
范围模式匹配以 \verb'doctor' 开始, 以空白行结束的记录 (\verb'/^$/' 匹配 
一个空白行).

为了从输出中移除掉头部信息, 我们可以用 
\begin{awkcode}
    /^doctor/ { p = 1; next }
    p == 1
    /^$/      { p = 0; next }
\end{awkcode}
这个程序使用了一个变量控制行的打印, 如果当前输入行包含有期望的头部信息,
则 \verb'p' 被设置为 1, 随后的尾部信息将 \verb'p' 重置为 0 (也就是 \verb'p'
的初始值). 因为仅当 \verb'p' 为 1 时才会把当前输入行打印出来, 所以程序只
打印记录的主体部分与尾部, 而选择其他输出组合反而比较简单.

\subsection{\mbox{名字}-值}
\label{subsec:name_value_data}


\marginpar{86}
某些应用的数据更加结构化, 这些数据没办法表示成一系列非结构化的文本, 例如,
地址可能含有国家名称, 也可能不包括街道地址.

处理结构化数据的一种方法是为记录的每一个字段加上一个名字或关键词, 例如,
我们有可能如此组织一本支票薄:
\begin{awkcode}
    check	1021
    to	Champagne Unlimited
    amount	123.10
    date	1/1/87

    deposit
    amount	500.00
    date	1/1/87

    check	1022
    date	1/2/87
    amount	45.10
    to	Getwell Drug Store
    tax	medical

    check	1023
    amount	125.00
    to	International Travel
    date	1/3/87

    amount	50.00
    to	Carnegie Hall
    date	1/3/87
    check	1024
    tax	charitable contribution

    to	American Express
    check	1025
    amount	75.75
    date	1/5/87
\end{awkcode}
我们仍然使用多行记录, 记录之间用一个空白行分隔, 但是在记录内部, 每一个 
数据都是自描述的: 每一个字段都由一个条目名称, 一个制表符, 及信息组成.
这意味着不同的记录可以包含不同的字段, 即使是类似的字段, 其排列顺序也可以
不一样.

处理这种数据的方法之一是把它们都当作单行数据, 但是要注意空白行被当作
分隔符. 每一行都指出了字段的名称及其所对应的值, 行与行之间没有其他过多的联
系, 比如说, 如果我们想要计算存款与支票的总额, 只需要扫描存款项与支票项即可:
\marginpar{87}
\begin{awkcode}
    # check1 - print total deposits and checks

    /^check/   { ck = 1; next }
    /^deposit/ { dep = 1; next }
    /^amount/  { amt = $2; next }
    /^$/       { addup() }

    END        { addup()
                 printf("deposits $%.2f, checks $%.2f\n",
                     deposits, checks)
               }

    function addup() {
        if (ck)
            checks += amt
        else if (dep)
            deposits += amt
        ck = dep = amt = 0
    }
\end{awkcode}
输出是 
\begin{awkcode}
    deposits $500.00, checks $418.95
\end{awkcode}

程序非常简单, 只要输入数据格式正确, 不管记录中的条目以何种顺序出现, 程序 
都能正确地工作. 但是程序也很脆弱, 它需要非常认真地初始化, 以及对文件结束
标志的处理. 我们还有另一种方案可供选择, 那就是一次读取一条记录, 当需要时
再对记录的条目进行挑选. 下面的程序也是对存款与支票进行求和, 但它使用了一
个函数, 这个函数提取具有指定名字的条目的值:
\begin{awkcode}
    # check2 - print total deposits and checks

    BEGIN           { RS = ""; FS = "\n" }
    /(^|\n)deposit/ { deposits += field("amount"); next }
    /(^|\n)check/   { checks += field("amount"); next }
    END             { printf("deposits $%.2f, checks $%.2f\n",
                          deposits, checks)
                    }

    function field(name,   i,f) {
        for (i = 1; i <= NF; i++) {
            split($i, f, "\t")
            if (f[1] == name)
                return f[2]
        }
        printf("error: no field %s in record\n%s\n", name, $0)
    }
\end{awkcode}
函数 \verb'field(s)' 在当前记录中搜索名字是 \verb's' 的条目, 如果找到, 就
把该项的值返回.

第 3 种方案是把记录的每一个字段都分割到一个关联数组中, 然后再对值进行访问.
下面将要介绍的程序以一种更加紧凑的方式打印支票信息:
\marginpar{88}
\begin{awkcode}
      1/1/87  1021  $123.10  Champagne Unlimited
      1/2/87  1022   $45.10  Getwell Drug Store
      1/3/87  1023  $125.00  International Travel
      1/3/87  1024   $50.00  Carnegie Hall
      1/5/87  1025   $75.75  American Express
\end{awkcode}
程序的代码是
\begin{awkcode}
    # check3 - print check information

    BEGIN { RS = ""; FS = "\n" }
    /(^|\n)check/ {
        for (i = 1; i <= NF; i++) {
            split($i, f, "\t")
            val[f[1]] = f[2]
        }
        printf("%8s %5d %8s  %s\n",
            val["date"],
            val["check"],
            sprintf("$%.2f", val["amount"]),
            val["to"])
        for (i in val)
            delete val[i]
    }
\end{awkcode}
利用 \verb'sprintf', 我们在总额的前面加上了美元符, 然后 \verb'printf' 再对
字符串进行右对齐后输出.

\begin{exercise}
    写一个命令 \texttt{lookup}\ \textit{x}\ \textit{y}, 该命令从已知的文件
中打印所有符合条件的多行记录, 条件是记录含有名字为 \textit{x} 且值为
    \textit{y} 的项.
\end{exercise}

\section{小结}
\label{sec:data_processing_summary}

在这一章, 我们展示了多种不同种类的数据处理程序, 它们包括: 从地址薄中获取
信息, 从数值数据中计算简单的统计信息, 检查程序或数据的有效性, 等等.
使用 awk 完成这些工作非常简单, 其中原因是多方面的: \patact 模型非常适合
这种类型的工作, 可调整的字段与记录分隔符可以适应不同格式与形状的输入数据,
关联数组无论是存储数值, 还是字符串都非常方便, 像 \verb'split' 与 
\verb'substr' 这样的函数擅长文本数据的挑选, 而 \printf 则是一个灵活的
格式化工具. 在下面的章节里, 我们将会看到这些功能更进一步的应用.
